公开完整前后端的代码

This commit is contained in:
Linus Torvalds
2026-02-26 19:22:38 +08:00
commit 193de8a34f
161 changed files with 17373 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import logo from '$lib/assets/logo-256.svg';
import { Theme } from 'carbon-components-svelte';
let { children } = $props();
// import 'carbon-components-svelte/css/g10.css'; //主样式
import Dock from '$lib/components/Dock.svelte';
import 'carbon-components-svelte/css/all.css';
import type { CarbonTheme } from 'carbon-components-svelte/src/Theme/Theme.svelte';
let theme: CarbonTheme = $state('g10');
</script>
<svelte:head><link rel="icon" href={logo} /></svelte:head>
<div class="app-container">
<Theme bind:theme />
<!--页面组件将在这里渲染 -->
<main class="main-content">
{@render children()}
</main>
<Dock />
</div>
<style>
:global(body) {
margin: 0;
padding: 0;
box-sizing: border-box;
/* 使用 Carbon 推荐的字体 */
font-family: 'IBM Plex Sans', sans-serif;
}
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
margin: 0 auto;
max-width: 500px;
box-shadow: 0 0 25px rgba(0, 0, 0, 0);
position: relative;
}
.main-content {
flex-grow: 1;
padding: 1rem;
padding-bottom: 110px;
}
/* 全局修复 Carbon Select 组件的双箭头问题 */
/* 不知道为什么会有这个奇怪的问题大概是Tailwind CSS导致的 */
:global(.bx--select-input) {
-webkit-appearance: none !important;
-moz-appearance: none !important;
appearance: none !important;
background-image: none !important;
}
:global(.bx--select-input::-ms-expand) {
display: none;
}
</style>

View File

@@ -0,0 +1,7 @@
import type { LayoutLoad } from './$types';
export const load = (async () => {
return {};
}) satisfies LayoutLoad;
export const prerender = true;

View File

@@ -0,0 +1,71 @@
<script>
import RetroCard from '$lib/components/RetroCard.svelte';
import { Button } from 'carbon-components-svelte';
import group from '$lib/assets/picture/girl-using-computer.svg';
import help from '$lib/assets/picture/help.svg';
import outlook from '$lib/assets/picture/two-people-credit-card.svg';
</script>
<h1 style="font-size: 25px">欢迎使用ZSC网维报修系统</h1>
<br />
<hr />
<br />
<br />
<RetroCard>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<div>
<h2>😵网络遇到了问题?</h2>
<p>点击下方按钮,向我们提交报修。</p>
<Button href="/repair/new">我要报修!</Button>
</div>
<img
src={help}
alt="Need help with network?"
style="width: 120px; height: 120px; object-fit: contain; margin-left: auto; align-self: flex-end;"
/>
</div>
</RetroCard>
<br />
<br />
<br />
<RetroCard>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<img
src={outlook}
alt="how to use campus network"
style="width: 120px; height: 120px; object-fit: contain; margin-right: 0.5rem;"
/>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<h2>🥺不会使用校园网?</h2>
<p>点击下方按钮,我们准备了校园网方方面面的攻略!</p>
<Button href="/help" kind="secondary" style="align-self: flex-end;transform: translate(-25px,0px);">网络攻略</Button>
</div>
</div>
</RetroCard>
<br />
<br />
<br />
<RetroCard>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<div>
<h2>🧑‍🤝‍🧑网络支持群</h2>
<p style="display: inline-block; margin: 0 0 0.5rem 0;">
如果有其它问题您可以加入我们的网络支持QQ群我们的工作人员会详细解答任何问题。
</p>
<p style="display: inline-block; margin: 0;">
群号:<strong>123123123</strong>
</p>
</div>
<img
src={group}
alt="Join our support group"
style="width: 120px; height: 120px; object-fit: contain;"
/>
</div>
</RetroCard>

View File

@@ -0,0 +1,23 @@
<h1>关于网维</h1>
<br />
<hr />
<br />
<p>中山学院网络维护科是一个独立的<strong>学生组织</strong>成立于2005年接受校信息中心的指导和管理。</p>
<p>
网维的主要任务是保证校园宿舍网络的正常运行,处理解决在校学生所报修的有线校园网问题,这项业务主要通过依托于微信的网络报修系统来运行。
</p>
<p>
在网维工作没有工资,促使我们成立这个组织的原因是对网络技术的热爱和对校园网正常运行的责任感,我们的宗旨是全心全意为同学服务。我们对外认真负责,在内和谐友善。
目前我们与三大运营商建立了良好的合作关系承接运营商在校园内的装维业务在信息中心的关怀下我们也承担学校部分IT后勤工作。
</p>
<p>
对于学生校园网报修,我们的成员会在<strong>每天16:30~18:00</strong>统一上门解决问题,用户在这个时间段必须本人在宿舍。
</p>
<p>
网维一般会在每年的9~10月招新对象为当年新生要求有基础的计算机知识。具体招新事宜请以实际通告为准。
</p>
<br />
<hr />
<br />
<a href="/about/TOS">报修系统的服务条款</a> |
<a href="/about/privacy">报修系统会如何处理您的个人信息?</a>

View File

@@ -0,0 +1 @@
<h1>服务条款</h1>

View File

@@ -0,0 +1,64 @@
<script>
import RetroCard from "$lib/components/RetroCard.svelte";
</script>
<h1>隐私权条款</h1>
<br />
<hr />
<br />
<p>
网维在向您提供服务时,需要和您的个人信息打交道。我们非常注重用户的数字个人隐私,对于有关问题,我们做出以下承诺:
</p>
<br/>
<h2>我们需要您的什么信息?</h2>
<p>为了派遣成员上门为您维修网络问题,在您注册报修系统时,我们需要获取您的:</p>
<br/>
<ul>
<li>宿舍住址</li>
<li>联系电话</li>
<li>校园卡账号</li>
</ul>
<br/>
<p>此外,为了确认您身份的真实性,我们还需要您的学号和真实姓名。</p>
<p>
在初次注册时,您的微信账户与您提交的学号和真实姓名绑定,原则上无法改动,而其余个人信息皆可以随时修改。
</p>
<br/>
<h2>我们如何保管您的信息?</h2>
<p>
我们使用妥善的措施,将您的个人信息保护在多项安全机制之内。除非根据下文所描述的信息使用条款合规将您的信息提供给有关人员之外,您提交给我们的个人信息绝不会被任何个人与集体知晓。
</p>
<br/>
<p>
当您修改您的个人信息,提交成功后,您的旧信息将在现有数据库中被永久删除。但是我们会定期备份我们的数据库,其中可能包含了旧的个人信息。不过,这些备份会被加密妥善保存,除非报修系统数据库遇到了损坏的情况,需要恢复备份的,否则任何人都无权访问这些数据。
</p>
<br/>
<h2>我们如何使用您的信息?</h2>
<p>
为了为您进行上门维修服务,当您在报修系统中有未解决的工单时,您的姓名,联系电话,宿舍住址和校园网账号将会在报修系统后台中被展示给有关的网维成员,以便它们进行上门维修或联系您进行其它相关事宜。当工单完成或被取消时,您的个人信息也将对普通网维成员不可见,但是您的信息会随着工单处理记录在管理层后台中保留一段时间,以供网维的管理层来进行审计工作。
</p>
<br/>
<p>
当遇到网维无法解决的问题,我们将启动上报程序,将您的网络问题告知运营商方面的有关工程师。此时,您的姓名,住址,学号等个人信息将会通过网维和运营商的对接机制,妥善地随着工单一并发送给您校园卡所在的运营商。
</p>
<br/>
<p>
除此之外,<strong
>我们不会将您的个人信息以任何方式泄露给任何个人或集体,尤其不会将您的信息用于商业营销或广告方面</strong
>
</p>
<br/>
<p>
如果您发现有网维成员或者运营商方面有关人员使用您提供给网维的个人信息,进行除了网络维修及其有关事宜之外的活动的,您可以通过多种方式进行投诉:
</p>
<br/>
<ul>
<li>在我们的报修系统后台聊天栏进行留言</li>
<li>加入我们的网络支持QQ群私聊群管理反馈问题。</li>
</ul>
<br/>
<br />
<hr />
<br />
<p>本条款最后更新于2025年12月27日最终解释权归网维所有。</p>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
import { onMount } from 'svelte';
import { Guard } from '$lib/jwt';
import { IsAdmin } from '$lib/types/enum';
onMount(() => Guard(IsAdmin));
</script>
<h1>管理后台</h1>
<br />
<hr />
<br />
<p>正在开发中</p>

View File

@@ -0,0 +1,226 @@
<script lang="ts">
import { CheckAndGetJWT, Guard } from '$lib/jwt';
import type { NewTicketReq } from '$lib/types/apiRequest';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
import { RFC3339 } from '$lib/types/RFC3339';
import { onMount } from 'svelte';
import { IsAdmin, IsUser } from '$lib/types/enum';
import {
DatePicker,
DatePickerInput,
RadioButtonGroup,
RadioButton,
TextArea,
Button,
NotificationQueue,
Loading,
TextInput,
Select,
SelectItem
} from 'carbon-components-svelte';
import { IsRFC3339 } from '$lib/types/RFC3339';
import { invalidState } from '$lib/types/invalidState.svelte';
import { NewTicket } from '$lib/api';
import { goto } from '$app/navigation';
let notLoading: boolean = $state(true);
let q: NotificationQueue;
let r = $state({
priority: 'normal',
} as NewTicketReq);
function onOccurDateChange(event: CustomEvent) {
const { dateStr } = event.detail;
if (dateStr) {
r.occur_at = RFC3339(dateStr);
}
}
function onAppointDateChange(event: CustomEvent) {
const { dateStr } = event.detail;
if (dateStr) {
const date = new Date(dateStr);
date.setHours(16, 30, 0, 0); // Set time to 16:30:00
r.appointed_at = RFC3339(date);
}
}
function handleSubmit() {
console.log('提交的表单数据:', r);
check() ? submit() : jumpInvalid();
}
let occurAt = new invalidState();
let appointedAt = new invalidState();
let description = new invalidState();
let notes = new invalidState();
function check(): boolean {
notLoading = false;
let ok = false;
occurAt.reset();
appointedAt.reset();
description.reset();
notes.reset();
occurAt.assert(!r.occur_at || IsRFC3339(r.occur_at), '请输入正确的故障发生时间');
appointedAt.assert(!r.appointed_at || IsRFC3339(r.appointed_at), '请输入正确的预约时间');
description.assert(r.description && r.description.length > 0, '请填写故障描述');
description.assert(r.description.length <= 100, '字数太多了请控制在100字以内');
notes.assert(!r.notes || r.notes.length <= 100, '字数太多了...请控制在100字以内');
if (r.category == undefined) {
r.category = 'others';
}
if (!r.occur_at) {
r.occur_at = undefined;
}
if (!r.appointed_at) {
r.appointed_at = undefined; //防止序列化问题
}
notLoading = true;
if (occurAt.notOK || appointedAt.notOK || description.notOK || notes.notOK) {
ok = false;
} else {
ok = true;
}
return ok;
}
async function submit() {
try {
notLoading = false;
let res = await NewTicket(r);
notLoading = true;
if (!res.success) {
throw new Error(res.msg || '提交失败.........');
}
q.add({
kind: 'success',
title: '提交成功',
timeout: 3000
});
setTimeout(() => goto('/repair'), 3900);
} catch (e: any) {
notLoading = true;
const errMsg = e.response?.data?.msg || e.message || '未知错误';
q.add({
kind: 'error',
title: '提交失败',
subtitle: errMsg + ',请重试',
timeout: 5000
});
}
}
function jumpInvalid() {
if (occurAt.notOK) {
document.getElementById('occur_at')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (appointedAt.notOK) {
document
.getElementById('appointed_at')
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (description.notOK) {
document
.getElementById('description')
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (notes.notOK) {
document.getElementById('notes')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
onMount(() => Guard(IsAdmin));
</script>
<h1>增添工单</h1>
<br />
<hr />
<br />
<p>为他人增添报修,注意,首先需要知道他人的学号。单独的工单增添正在开发中...</p>
<br />
<TextInput labelText="用户的学号" placeholder="请输入用户学号" bind:value={r.issuer_sid}/>
<br/>
<br/>
<DatePicker datePickerType="single" on:change={onOccurDateChange}>
<DatePickerInput
labelText="故障发生的日期"
placeholder="记不清楚可不填"
invalid={occurAt.notOK}
invalidText={occurAt.txt}
/>
</DatePicker>
<br />
<br />
<RadioButtonGroup
legendText="故障问题的类型"
orientation="vertical"
bind:selected={r.category}
required={true}
>
<RadioButton labelText="需要新安装宽带" value="first-install" />
<RadioButton labelText="IP地址或者网络设备问题" value="ip-or-device" />
<RadioButton labelText="电脑软件或者账号的问题" value="client-or-account" />
<RadioButton labelText="网速问题" value="low-speed" />
<RadioButton labelText="其它问题/不清楚" value="others" />
</RadioButtonGroup>
<br />
<br />
<TextArea
labelText="故障描述"
placeholder="描述一下故障的情况"
bind:value={r.description}
invalid={description.notOK}
invalidText={description.txt}
/>
<br />
<br />
<DatePicker datePickerType="single" on:change={onAppointDateChange}>
<DatePickerInput
labelText="预约上门维修的日期"
placeholder="当天4:30~6:00用户需要在宿舍"
invalid={appointedAt.notOK}
invalidText={appointedAt.txt}
/>
</DatePicker>
<br />
<br />
<hr />
<br />
<br />
<TextArea
labelText="备注"
placeholder="其它对维修成员有用的信息,注意事项等。"
bind:value={r.notes}
invalid={notes.notOK}
invalidText={notes.txt}
/>
<br />
<br />
<Select
labelText="工单优先级"
bind:selected={r.priority}
helperText="选择工单的优先级类型,更高优先级会在系统中优先显示"
>
<SelectItem value="highest" text="十万火急" style="color: var(--color-red-600);" />
<SelectItem value="assigned" text="运营商工单" style="color: #2563eb;" />
<SelectItem value="mainline" text="主线任务" />
<SelectItem value="normal" text="一般报修" />
<SelectItem value="in-passing" text="顺路看看" />
<SelectItem value="least" text="不紧急" />
</Select>
<br/>
<br/>
<Button on:click={handleSubmit}>提交</Button>
<NotificationQueue bind:this={q} />
<Loading active={!notLoading} />

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<h1>管理排班</h1>
<br />
<hr />
<br />
<p>正在开发中</p>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<h1>Forbidden</h1>
<p>对不起,您没有权限访问这个页面。</p>

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import { Accordion, AccordionItem } from 'carbon-components-svelte';
import RetroCard from '$lib/components/RetroCard.svelte';
</script>
<h1>校园网使用攻略</h1>
<hr />
<br />
<br />
<br />
<RetroCard>
<h3 style="font-size: 25px; ">⚙️ 常见问题</h3>
<br/>
<Accordion size="xl">
<AccordionItem title="电脑获取不到IP地址">
<p>
Natural Language Classifier uses advanced natural language processing and machine learning
techniques to create custom classification models. Users train their data and the service
predicts the appropriate category for the inputted text.
</p>
</AccordionItem>
<AccordionItem title="忘记了账号密码">
<p>
Analyze text to extract meta-data from content such as concepts, entities, emotion,
relations, sentiment and more.
</p>
</AccordionItem>
<AccordionItem title="Wi-Fi没法用了">
<p>
Translate text, documents, and websites from one language to another. Create industry or
region-specific translations via the service's customization capability.
</p>
</AccordionItem>
<AccordionItem title="电信客户端故障">
<p>
Translate text, documents, and websites from one language to another. Create industry or
region-specific translations via the service's customization capability.
</p>
</AccordionItem>
<AccordionItem title="出现169.254开头的IP">
<p>
Translate text, documents, and websites from one language to another. Create industry or
region-specific translations via the service's customization capability.
</p>
</AccordionItem>
</Accordion>
</RetroCard>
<br />
<br />
<RetroCard>
<h3 style="font-size: 25px; ">🤔 新生相关</h3>
<br/>
<Accordion size="xl">
<AccordionItem title="去哪里办校园网?">
<p>
Natural Language Classifier uses advanced natural language processing and machine learning
techniques to create custom classification models. Users train their data and the service
predicts the appropriate category for the inputted text.
</p>
</AccordionItem>
<AccordionItem title="我该办什么套餐?">
<p>
Analyze text to extract meta-data from content such as concepts, entities, emotion,
relations, sentiment and more.
</p>
</AccordionItem>
<AccordionItem title="如何把我的电脑手机连接到校园网?">
<p>
Translate text, documents, and websites from one language to another. Create industry or
region-specific translations via the service's customization capability.
</p>
</AccordionItem>
<AccordionItem title="我该如何登录校园网?">
<p>
Translate text, documents, and websites from one language to another. Create industry or
region-specific translations via the service's customization capability.
</p>
</AccordionItem>
</Accordion>
</RetroCard>
<br />
<br />
<RetroCard>
<h3 style="font-size: 25px; ">🧐 关于报修</h3>
<br/>
<Accordion size="xl">
<AccordionItem title="如何报修我的网络故障?">
<p>
Natural Language Classifier uses advanced natural language processing and machine learning
techniques to create custom classification models. Users train their data and the service
predicts the appropriate category for the inputted text.
</p>
</AccordionItem>
<AccordionItem title="我报修了,大概多久能上门?">
<p>
Analyze text to extract meta-data from content such as concepts, entities, emotion,
relations, sentiment and more.
</p>
</AccordionItem>
<AccordionItem title="怎么一直没人来?">
<p>
Translate text, documents, and websites from one language to another. Create industry or
region-specific translations via the service's customization capability.
</p>
</AccordionItem>
<AccordionItem title="你们的人没把问题解决就跑了!">
<p>
Translate text, documents, and websites from one language to another. Create industry or
region-specific translations via the service's customization capability.
</p>
</AccordionItem>
<AccordionItem title="我应该如何投诉网维成员?">
<p>
Translate text, documents, and websites from one language to another. Create industry or
region-specific translations via the service's customization capability.
</p>
</AccordionItem>
</Accordion>
</RetroCard>
<br />
<br />

View File

@@ -0,0 +1,59 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@plugin 'daisyui';
@plugin "daisyui/theme" {
name: "wireframe";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: #f4f4f4;
--color-base-200: #e6e6e6;
--color-base-300: #d5d5d5;
--color-base-content: #000000;
--color-primary: #0f62fe;
--color-primary-content: oklch(100% 0 360);
--color-secondary: #393939;
--color-secondary-content: oklch(100% 0 360);
--color-accent: #0f62fe;
--color-accent-content: oklch(100% 0 0);
--color-neutral: #0f62fe;
--color-neutral-content: oklch(100% 0 0);
--color-info: #0f62fe;
--color-info-content: oklch(100% 0 360);
--color-success: #006d44;
--color-success-content: oklch(100% 0 360);
--color-warning: oklch(68% 0.162 75.834);
--color-warning-content: #ffffff;
--color-error: #da1e28;
--color-error-content: oklch(100% 0 360);
--radius-selector: 2rem;
--radius-field: 0rem;
--radius-box: 0rem;
--size-selector: 0.25rem;
--size-field: 0.21875rem;
--border: 0.5px;
--depth: 1;
--noise: 1;
}
@layer base {
html {
/* 移动端优化:防止点击高亮 */
-webkit-tap-highlight-color: transparent;
/* 移动端优化:平滑滚动 */
scroll-behavior: smooth;
}
body {
/* 移动端优化:字体抗锯齿 */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* 移动端优化:防止水平溢出 */
@apply min-h-screen overflow-x-hidden bg-base-100 text-base-content;
}
}

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
import { dev } from '$app/environment';
import { env } from '$env/dynamic/public';
import { docCookies } from '$lib/vendor/docCookie';
import { PUBLIC_AUTH_REDIRECT } from '$env/static/public';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
function gotoAuthAPI() {
if (!env.PUBLIC_JWT) {
console.log("未找到PUBLIC_JWT")
}
if (dev && env.PUBLIC_JWT) {
docCookies.setItem('jwt', env.PUBLIC_JWT, Infinity, '/');
goto('/login/success');
} else {
window.location.href = PUBLIC_AUTH_REDIRECT;
}
}
onMount(() => {
gotoAuthAPI();
});
</script>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { CheckAndGetJWT, GetJWTFromCookie } from '$lib/jwt';
import { SelectProduct } from 'carbon-pictograms-svelte';
import type { PageProps } from './$types';
import { NotificationQueue } from 'carbon-components-svelte';
import { onMount } from 'svelte';
import { TheLastPage } from '$lib/states/theLastPage.svelte';
let { data }: PageProps = $props();
let q: NotificationQueue;
onMount(() => {
let ok = GetJWTFromCookie();
if (!ok) {
q.add({
kind: 'error',
title: '登录失败',
subtitle: '请查看控制台',
timeout: 5000
});
setTimeout(() => goto(TheLastPage.Read()), 5500);
return;
}
let isRegistered = CheckAndGetJWT('parsed').access !== 'unregistered';
if (!isRegistered) {
goto('/register');
return;
}
goto(TheLastPage.Read());
});
</script>
<h1>登录成功!请稍等...</h1>
<NotificationQueue bind:this={q} />

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import RetroCard from '$lib/components/RetroCard.svelte';
import type { UserProfile } from '$lib/types/apiResponse';
import {
Accordion,
AccordionItem,
Toggle,
Button,
StructuredList,
StructuredListBody,
StructuredListCell,
StructuredListRow,
Modal,
NotificationQueue,
ButtonSet
} from 'carbon-components-svelte';
import BlockRoom from '$lib/components/Ticket/BlockRoom.svelte';
import { ViewProfile } from '$lib/api';
import { CheckAndGetJWT, Guard } from '$lib/jwt';
import { onMount } from 'svelte';
import { IsAdmin, IsOperator, IsUser } from '$lib/types/enum';
import WtsISP from '$lib/components/Ticket/WtsISP.svelte';
import Renew from 'carbon-icons-svelte/lib/Renew.svelte';
import { TheLastPage } from '$lib/states/theLastPage.svelte';
import { goto } from '$app/navigation';
let pending = $state(true);
let info = $state({} as UserProfile);
let q: NotificationQueue;
onMount(() => (Guard(IsUser), fetchUser()));
async function fetchUser() {
try {
const wx = CheckAndGetJWT('parsed').openid;
if (!wx) {
throw new Error('未找到用户信息,请重新登录');
}
const res = await ViewProfile(wx);
console.log(res);
pending = false;
if (!res.success) {
throw new Error(res.msg || '获取用户信息失败');
}
info = res.profile;
} catch (e: any) {
pending = false;
const errMsg = e.response?.data?.msg || e.message || '未知错误';
q.add({
kind: 'error',
title: '获取用户信息失败',
subtitle: errMsg + ',请重试',
timeout: 5000
});
}
}
</script>
<h1></h1>
<br />
<hr />
<br />
<div class="profile">
{#await pending}
<p>加载中...</p>
{:then}
<RetroCard>
<span style="display: flex; align-items: center;">
<h2 style="margin-right: 0.5rem;">个人信息</h2>
<Renew onclick={() => {TheLastPage.Write('/me'),goto('/login')}} style="cursor: pointer;" />
</span>
<StructuredList style="margin-bottom: 1rem;">
<StructuredListBody>
<StructuredListRow>
<StructuredListCell noWrap>姓名</StructuredListCell>
<StructuredListCell>{info.name}</StructuredListCell>
</StructuredListRow>
<StructuredListRow>
<StructuredListCell noWrap>学号</StructuredListCell>
<StructuredListCell>{info.sid}</StructuredListCell>
</StructuredListRow>
<StructuredListRow>
<StructuredListCell noWrap>联系电话</StructuredListCell>
<StructuredListCell>{info.phone}</StructuredListCell>
</StructuredListRow>
<StructuredListRow>
<StructuredListCell noWrap>宿舍地址</StructuredListCell>
<StructuredListCell><BlockRoom b={info.block} r={info.room} /></StructuredListCell>
</StructuredListRow>
<StructuredListRow>
<StructuredListCell noWrap>账号</StructuredListCell>
<StructuredListCell>{info.account}</StructuredListCell>
</StructuredListRow>
<StructuredListRow>
<StructuredListCell noWrap>运营商</StructuredListCell>
<StructuredListCell><WtsISP i={info.isp} /></StructuredListCell>
</StructuredListRow>
</StructuredListBody>
</StructuredList>
<p style="font-size:small;margin-bottom:1rem;">
该信息用于我们提供上门服务,如有变化请修改:
</p>
<Button on:click={() => (window.location.href = '/me/update')}>修改信息</Button>
</RetroCard>
{/await}
{#if IsOperator(info.access)}
<br />
<br />
<br />
<RetroCard style="margin-bottom:1rem;">
<h2>网维操作</h2>
<p>在这里进入网维后台系统</p>
<br />
<ButtonSet stacked>
<Button on:click={() => (window.location.href = '/op')}>进入后台</Button>
{#if IsAdmin(info.access)}
<Button kind="ghost" on:click={() => (window.location.href = '/admin')}
>进入管理后台</Button
>
{/if}
</ButtonSet>
</RetroCard>
{/if}
</div>
<br />
<br />
<br />
<RetroCard>
<Accordion size="xl">
<AccordionItem title="设置">
<div>
<!-- <Theme bind:theme persist persistKey="__carbon-theme" /> -->
<Toggle labelText="深色模式(暂时用不了)" disabled />
</div>
</AccordionItem>
<AccordionItem title="联系我们">
<p>如果您对网维的服务或本系统有任何意见或建议,请尽管联系我们!我们非常重视您的建议。</p>
<br />
<p>ZSC学生网络支撑QQ群:123123123</p>
<br />
<p>科长QQ:</p>
<br />
<p>科长微信/电话:</p>
<br />
</AccordionItem>
<AccordionItem title="关于">
<a href="/about">关于网络维护科</a>
<br />
<br />
<hr />
<br />
<br />
<p>作者paakaauuxx</p>
<br />
<p>前端框架SvelteKit</p>
<br />
<p>UI框架Carbon Components Svelte</p>
<br />
<p>后端框架Go(Echo)+PostgreSQL(sqlc)</p>
<br />
<p>ZSCNetworkSupport 版权所有</p>
<br />
<!-- //TODO: 源码的地址-->
<p>Under AGPLv3,<a href="/">源代码</a></p>
</AccordionItem>
</Accordion>
</RetroCard>
<NotificationQueue bind:this={q} />

View File

@@ -0,0 +1,321 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
import {
TextInput,
RadioButtonGroup,
RadioButton,
Select,
SelectItem,
SelectItemGroup,
Button,
Checkbox,
ComposedModal,
ModalBody,
ModalFooter,
ModalHeader,
NotificationQueue,
Loading
} from 'carbon-components-svelte';
import type { ChangeProfileReq } from '$lib/types/apiRequest';
import { invalidState } from '$lib/types/invalidState.svelte';
import { ChangeProfile } from '$lib/api';
import { onMount } from 'svelte';
import { Guard, CheckAndGetJWT } from '$lib/jwt';
import { IsUser } from '$lib/types/enum';
onMount(() => Guard(IsUser));
let notLoading: boolean = $state(true);
let account = new invalidState();
let phone = new invalidState();
let block = new invalidState();
let room = new invalidState();
//模态框状态变量
let checked = $state(false);
let open = $state(false);
let req = $state({
block: '0'
} as unknown as ChangeProfileReq);
let q: NotificationQueue;
//检查输入合法性
function check(): boolean {
let ok = false;
// 重置所有无效状态
account.reset();
phone.reset();
block.reset();
room.reset();
// 然后校园网账号和手机号是中国大陆的11位手机号码
const phoneRegex = /^1[3-9]\d{9}$/;
account.assert(
req.isp === 'others' || phoneRegex.test(req.account),
'校园网账号应为有效的11位手机号'
);
account.assert(req.isp !== 'others' || req.account.length > 0, '请输入您的校园网账号');
account.assert(req.isp !== 'others' || req.account.length <= 15, '校园网账号不能超过15个字符');
phone.assert(phoneRegex.test(req.phone), '联系电话应为有效的11位手机号');
// 接着宿舍楼不能为空且房间号不能超过5个字符且不能为空
block.assert(req.block !== '0', '请选择宿舍楼');
room.assert(req.room.length > 0, '房间号不能为空');
room.assert(
req.block === 'other' || /^[0-9]{1,4}$/.test(req.room),
'请填写一个5位以内的纯数字...'
);
//最后,总结断言结果
if (account.notOK || phone.notOK || block.notOK || room.notOK || req.isp === undefined) {
ok = false;
} else {
ok = true;
}
return ok;
}
// 在不合法时跳转到对应的地方以便用户修改
function jump() {
if (account.notOK) {
document.getElementById('account')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (phone.notOK) {
document.getElementById('phone')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (block.notOK) {
document.getElementById('block')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (room.notOK) {
document.getElementById('room')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
if (req.isp === undefined) {
document.getElementById('isp')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
q.add({
kind: 'error',
title: '请选择校园卡运营商',
timeout: 3000
});
}
}
async function submit() {
req.who = CheckAndGetJWT('parsed').openid;
open = false;
checked = false;
notLoading = false;
try {
const res = await ChangeProfile(req);
notLoading = true;
if (!res.success) {
throw new Error(res.msg || '修改信息失败.........');
}
q.add({
kind: 'success',
title: '修改成功',
timeout: 3000
});
setTimeout(() => {
window.location.href = '/me';
}, 2500);
} catch (e: any) {
notLoading = true;
console.error('register fail:', e);
const errMsg = e.response?.data?.msg || e.message || '未知错误';
q.add({
kind: 'error',
title: '修改失败',
subtitle: errMsg + ',请重试',
timeout: 5000
});
}
}
</script>
<h1>修改个人信息</h1>
<br />
<hr />
<br />
<p>
您的个人信息被我们用于提供上门维修服务,如果信息有更新的,可以在这里修改。但是如果需要修改学号或者姓名则不能在这里修改,请联系我们手动修改。
</p>
<br />
<RadioButtonGroup
id="isp"
legendText="请选择您校园卡的运营商"
bind:selected={req.isp}
required={true}
>
<RadioButton labelText="电信" value="telecom" />
<RadioButton labelText="联通" value="unicom" />
<RadioButton labelText="移动" value="mobile" />
<RadioButton labelText="其它" value="others" />
</RadioButtonGroup>
<br />
<br />
<TextInput
id="account"
labelText="校园网账号"
placeholder="请输入您校园卡的手机号..."
bind:value={req.account}
invalid={account.notOK}
invalidText={account.txt}
/>
<br />
<br />
<hr />
<br />
<br />
<TextInput
id="phone"
labelText="电话"
placeholder="请输入您的联系电话..."
bind:value={req.phone}
invalid={phone.notOK}
invalidText={phone.txt}
/>
<br />
<br />
<Select
id="block"
labelText="宿舍楼"
bind:selected={req.block}
invalid={block.notOK}
invalidText={block.txt}
>
<SelectItem value="0" text="请选择您的所住的宿舍楼..." disabled hidden />
<SelectItemGroup label="凤翔宿舍区">
<SelectItem value="1" text="1栋" />
<SelectItem value="2" text="2栋" />
<SelectItem value="3" text="3栋" />
<SelectItem value="4" text="4栋" />
<SelectItem value="5" text="5栋" />
<SelectItem value="6" text="6栋" />
</SelectItemGroup>
<SelectItemGroup label="北门宿舍区">
<SelectItem value="7" text="7栋" />
<SelectItem value="8" text="8栋" />
<SelectItem value="9" text="9栋" />
<SelectItem value="10" text="10栋" />
<SelectItem value="11" text="11栋" />
</SelectItemGroup>
<SelectItemGroup label="东门宿舍区">
<SelectItem value="12" text="12栋" />
<SelectItem value="13" text="13栋" />
<SelectItem value="14" text="14栋" />
<SelectItem value="15" text="15栋" />
<SelectItem value="20" text="20栋" />
<SelectItem value="21" text="21栋" />
<SelectItem value="22" text="22栋" />
</SelectItemGroup>
<SelectItemGroup label="歧头山宿舍区">
<SelectItem value="16" text="16栋" />
<SelectItem value="17" text="17栋" />
<SelectItem value="18" text="18栋" />
<SelectItem value="19" text="19栋" />
</SelectItemGroup>
<SelectItemGroup label="香晖苑">
<SelectItem value="XHA" text="香晖苑-A栋" />
<SelectItem value="XHB" text="香晖苑-B栋" />
<SelectItem value="XHC" text="香晖苑-C栋" />
<SelectItem value="XHD" text="香晖苑-D栋" />
</SelectItemGroup>
<SelectItemGroup label="朝晖苑">
<SelectItem value="ZH" text="朝晖苑" />
</SelectItemGroup>
<SelectItemGroup label="其它">
<SelectItem value="other" text="其它" />
</SelectItemGroup>
</Select>
<br />
<br />
<TextInput
id="room"
labelText="房间号"
placeholder="请输入您所住的房间..."
bind:value={req.room}
invalid={room.notOK}
invalidText={room.txt}
/>
<br />
<br />
<Button
on:click={() => {
check() ? (open = true) : jump();
}}>提交注册</Button
>
<ComposedModal
bind:open
on:close={() => {
((open = false), (checked = false));
}}
class="mobile-floating-modal"
>
<ModalHeader title="确认您的信息" />
<ModalBody hasForm>
<Checkbox labelText="我确认所填信息准确无误,真实有效,且未盗用他人信息" bind:checked />
<br />
<br />
</ModalBody>
<ModalFooter>
<Button kind="secondary" on:click={() => ((open = false), (checked = false))}>取消</Button>
<Button
kind="primary"
disabled={!checked}
on:click={() => {
submit();
}}>确认并提交</Button
>
</ModalFooter>
</ComposedModal>
<NotificationQueue bind:this={q} />
<Loading active={!notLoading} />
<NotificationQueue bind:this={q} />
<Loading active={!notLoading} />
<style>
:global(.mobile-floating-modal.bx--modal) {
@media (max-width: 672px) {
display: flex !important;
align-items: center !important;
justify-content: center !important;
/* 确保背景色存在 (Carbon默认有但为了保险起见) */
background-color: rgba(22, 22, 22, 0.5) !important;
}
}
:global(.mobile-floating-modal .bx--modal-container) {
@media (max-width: 672px) {
width: 90% !important;
max-width: 400px !important;
height: auto !important;
max-height: 85vh !important;
position: relative !important;
margin: 0 !important;
top: auto !important;
left: auto !important;
transform: none !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4) !important;
}
}
:global(.mobile-floating-modal .bx--modal-content) {
@media (max-width: 672px) {
max-height: 60vh !important;
overflow-y: auto !important;
margin-bottom: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,176 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
import { onMount } from 'svelte';
import { CheckAndGetJWT, Guard } from '$lib/jwt';
import { IsOperator, ZoneMap, ZoneToBlock, type WtsBlock, type WtsZone } from '$lib/types/enum';
import { TicketOverview } from '$lib/api';
import { NotificationQueue, Tile } from 'carbon-components-svelte';
import { Radio } from 'carbon-icons-svelte';
import { criteria, type Criteria } from '$lib/states/ticketCriteriaSearch.svelte';
import { PriorityMap, type WtsPriority } from '$lib/types/enum';
import { CategoryMap, type WtsCategory } from '$lib/types/enum';
import { ISPMap, type WtsISP } from '$lib/types/enum';
import { goto } from '$app/navigation';
let name: string = $state('网维成员');
onMount(() => Guard(IsOperator));
onMount(() => {
name = CheckAndGetJWT('parsed').name;
});
onMount(() => getTicketOverview());
let countByBlock: Record<WtsBlock, number> = $state(undefined);
let countByZone: Record<WtsZone, number> = $state(undefined);
const zoneDisplayOrder: WtsZone[] = ['FX', 'BM', 'DM', 'QT', 'XHAB', 'XHCD', 'ZH'];
function zoneTone(count: number | undefined) {
const value = count ?? 0;
if (value === 0) return 'none';
if (value <= 5) return 'green';
if (value <= 15) return 'yellow';
return 'red';
}
async function getTicketOverview() {
try {
let res = await TicketOverview();
if (!res.success) {
throw new Error(res.msg || '获取片区总览失败');
}
countByBlock = res.count_by_block;
parseTicketCount();
} catch (e: any) {
const errMsg = e.response?.data?.msg || e.message || '未知错误';
q.add({
kind: 'error',
title: '获取片区总览失败',
subtitle: errMsg + ',请重试',
timeout: 5000
});
}
}
let q: NotificationQueue;
function parseTicketCount() {
const zoneCounts: Record<WtsZone, number> = {} as Record<WtsZone, number>;
(Object.keys(ZoneToBlock) as WtsZone[]).forEach((zone) => {
const blocks = ZoneToBlock[zone];
zoneCounts[zone] = blocks.reduce((acc, block) => {
return acc + (countByBlock?.[block] ?? 0);
}, 0);
});
countByZone = zoneCounts;
}
function search(zone: WtsZone): Criteria {
return {
r: {
scope: 'active',
issuer: undefined,
block: ZoneToBlock[zone],
status: ['fresh','scheduled','escalated','delay'],
priority: Object.keys(PriorityMap) as WtsPriority[],
category: Object.keys(CategoryMap) as WtsCategory[],
isp: Object.keys(ISPMap) as WtsISP[],
newer_than: undefined,
older_than: undefined
},
_order: 'priority',
_floor: null,
_blocks_in_zone: [zone],
_view_today_scheduled: true
} as Criteria;
}
function jumpSearch(zone: WtsZone){
Object.assign(criteria, search(zone));
goto('/op/tickets');
}
</script>
<h1>报修操作后台</h1>
<br />
<hr />
<br />
<p>
你好,{name}!今天修了多少单?
</p>
<br />
<br />
<br />
<br />
<br />
<h2>片区总览</h2>
<p>每个片区的报修单数量</p>
<br />
<div class="zone-tiles">
{#each zoneDisplayOrder as zone}
{#if typeof countByZone?.[zone] !== 'undefined'}
<Tile class={`zone-tile zone-${zoneTone(countByZone?.[zone])}`} on:click={() => jumpSearch(zone)}>
<span class="zone-name">{ZoneMap[zone]}</span>
<span class="zone-count">{countByZone?.[zone] ?? 0}</span>
</Tile>
{:else}
<Tile class="zone-tile zone-none" on:click={() => jumpSearch(zone)}>
<span class="zone-name">{ZoneMap[zone]}</span>
<span class="zone-count">0</span>
</Tile>
{/if}
{/each}
</div>
<NotificationQueue bind:this={q} />
<style>
.zone-tiles {
display: flex;
flex-direction: column;
gap: 0;
}
:global(.zone-tile) {
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid #c6c6c6;
padding: 0.5rem 0.75rem;
}
.zone-name {
flex: 1;
}
.zone-count {
text-align: right;
margin-left: auto;
min-width: 2ch;
font-variant-numeric: tabular-nums;
color: var(--cds-text-secondary, #525252);
font-weight: 600;
}
:global(.zone-none) {
background-color: #ffffff;
}
:global(.zone-green) {
background-color: #d9fbdb; /* Green 10 */
}
:global(.zone-yellow) {
background-color: #fcf4d6; /* Yellow 10 */
}
:global(.zone-red) {
background-color: #fff1f1; /* Red 10 */
}
</style>

View File

@@ -0,0 +1,5 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>

View File

@@ -0,0 +1,416 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
import { CheckAndGetJWT, Guard } from '$lib/jwt';
import { IsAdmin, IsOperator, PriorityMap, CategoryMap, ISPMap } from '$lib/types/enum';
import { onMount } from 'svelte';
import {
RadioButtonGroup,
RadioButton,
Checkbox,
CheckboxGroup,
DatePicker,
DatePickerInput,
TimePicker,
Grid,
Row,
Column,
NumberInput,
Button,
NotificationQueue,
Toggle
} from 'carbon-components-svelte';
import type { FilterTicketsReq } from '$lib/types/apiRequest';
import { ZoneMap, ZoneToBlock, StatusMap, type WtsZone } from '$lib/types/enum';
import { RFC3339 } from '$lib/types/RFC3339';
import { startOfDay, endOfDay } from 'date-fns';
import { criteria } from '$lib/states/ticketCriteriaSearch.svelte';
import { goto } from '$app/navigation';
import type { WtsStatus, WtsPriority, WtsCategory, WtsISP } from '$lib/types/enum';
onMount(() => Guard(IsOperator));
let req = $state(criteria.r as FilterTicketsReq);
let zoneSelected: WtsZone[] = $state(criteria._blocks_in_zone ?? []);
let order: 'priority' | 'newest' | 'oldest' = $state(criteria._order ?? 'priority');
let floor: number | null = $state(criteria._floor ?? null);
let viewTodayScheduled = $state(criteria._view_today_scheduled ?? false);
let isScheduledSelected = $state(req.status?.includes('scheduled') ?? false);
// $effect(() => {
// $inspect(req);
// $inspect(zoneSelected);
// $inspect(order);
// $inspect(floor);
// });
let onDateChange = (which: 'newer' | 'older') => (event: CustomEvent) => {
const { dateStr } = event.detail;
if (dateStr) {
const date = new Date(dateStr);
const adjustedDate = which === 'newer' ? startOfDay(date) : endOfDay(date);
const rfcDate = RFC3339(adjustedDate);
if (which === 'newer') {
req.newer_than = rfcDate;
} else {
req.older_than = rfcDate;
}
}
};
let q: NotificationQueue;
function search() {
req.block = zoneSelected.flatMap((zone) => ZoneToBlock[zone as WtsZone]);
criteria.r = $state.snapshot(req);
criteria._blocks_in_zone = $state.snapshot(zoneSelected) as WtsZone[];
criteria._order = $state.snapshot(order);
criteria._floor = $state.snapshot(floor);
criteria._view_today_scheduled = $state.snapshot(viewTodayScheduled);
console.log(criteria);
setTimeout(() => goto('/op/tickets'), 500);
}
const allZones = Object.keys(ZoneMap) as WtsZone[];
const allStatuses = IsAdmin(CheckAndGetJWT('parsed').access)
? (Object.keys(StatusMap) as WtsStatus[])
: (Object.keys(StatusMap).filter(
(status) => status !== 'solved' && status !== 'canceled'
) as WtsStatus[]);
const allPriorities = Object.keys(PriorityMap) as WtsPriority[];
const allCategories = Object.keys(CategoryMap) as WtsCategory[];
const allISPs = Object.keys(ISPMap) as WtsISP[];
const zoneOptions = [
'FX',
'BM',
'DM',
'QT',
'XHAB',
'XHCD',
'ZH',
'other'
] as const satisfies readonly WtsZone[];
const statusOptionsAdmin = [
'fresh',
'scheduled',
'delay',
'escalated',
'solved',
'canceled'
] as const satisfies readonly WtsStatus[];
const statusOptionsUser = [
'fresh',
'scheduled',
'delay',
'escalated'
] as const satisfies readonly WtsStatus[];
const statusOptions: readonly WtsStatus[] = IsAdmin(CheckAndGetJWT('parsed').access)
? statusOptionsAdmin
: statusOptionsUser;
const priorityOptions = [
'highest',
'assigned',
'mainline',
'normal',
'in-passing',
'least'
] as const satisfies readonly WtsPriority[];
const categoryOptions = [
'first-install',
'low-speed',
'ip-or-device',
'client-or-account',
'others'
] as const satisfies readonly WtsCategory[];
const ispOptions = ['telecom', 'unicom', 'mobile', 'others'] as const satisfies readonly WtsISP[];
function allSelected(selected: readonly string[] | null | undefined, options: readonly string[]) {
if (!selected) return false;
return options.length > 0 && options.every((o) => selected.includes(o));
}
function uniq<T>(arr: T[]) {
return Array.from(new Set(arr));
}
function sameArray<T>(a: readonly T[], b: readonly T[]) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
$effect(() => {
req.status ??= [];
req.priority ??= [];
req.category ??= [];
req.isp ??= [];
const nextZone = uniq(zoneSelected).filter((z) =>
(zoneOptions as readonly WtsZone[]).includes(z)
);
if (!sameArray(zoneSelected, nextZone)) zoneSelected = nextZone;
const nextStatus = uniq(req.status).filter((s) =>
(statusOptions as readonly WtsStatus[]).includes(s)
);
if (!sameArray(req.status, nextStatus)) req.status = nextStatus;
const nextPriority = uniq(req.priority).filter((p) =>
(priorityOptions as readonly WtsPriority[]).includes(p)
);
if (!sameArray(req.priority, nextPriority)) req.priority = nextPriority;
const nextCategory = uniq(req.category).filter((c) =>
(categoryOptions as readonly WtsCategory[]).includes(c)
);
if (!sameArray(req.category, nextCategory)) req.category = nextCategory;
const nextIsp = uniq(req.isp).filter((i) => (ispOptions as readonly WtsISP[]).includes(i));
if (!sameArray(req.isp, nextIsp)) req.isp = nextIsp;
});
$effect(() =>{
isScheduledSelected = req.status?.includes('scheduled') ?? false;
})
</script>
<h1>报修单检索</h1>
<br />
<hr />
<br />
<p>选择您需要检索报修工单的条件</p>
{#if IsAdmin(CheckAndGetJWT('parsed').access)}
<br />
<RadioButtonGroup id="scope" legendText="范围" bind:selected={req.scope} required={true}>
<RadioButton labelText="只看活跃的" value="active" />
<RadioButton labelText="所有报修单" value="all" />
</RadioButtonGroup>
{/if}
<br />
<DatePicker datePickerType="single" on:change={onDateChange('newer')}>
<DatePickerInput labelText="只看这之后的报修单:" placeholder="从该日开始时起" />
</DatePicker>
<br />
<DatePicker datePickerType="single" on:change={onDateChange('older')}>
<DatePickerInput labelText="只看这之前的报修单:" placeholder="从该日结束时起" />
</DatePicker>
<!-- TODO:可以选择时间 -->
<br />
<CheckboxGroup legendText="片区" id="block" bind:selected={zoneSelected} required={true}>
<Grid narrow>
<Row>
<Column sm={2} md={2} lg={4}><Checkbox value="FX" labelText={ZoneMap['FX']} /></Column>
<Column sm={2} md={2} lg={4}><Checkbox value="BM" labelText={ZoneMap['BM']} /></Column>
<Column sm={2} md={2} lg={4}><Checkbox value="DM" labelText={ZoneMap['DM']} /></Column>
<Column sm={2} md={2} lg={4}><Checkbox value="QT" labelText={ZoneMap['QT']} /></Column>
<Column sm={2} md={2} lg={4}><Checkbox value="XHAB" labelText={ZoneMap['XHAB']} /></Column>
<Column sm={2} md={2} lg={4}><Checkbox value="XHCD" labelText={ZoneMap['XHCD']} /></Column>
<Column sm={2} md={2} lg={4}><Checkbox value="ZH" labelText={ZoneMap['ZH']} /></Column>
<Column sm={2} md={2} lg={4}><Checkbox value="other" labelText={ZoneMap['other']} /></Column>
</Row>
</Grid>
</CheckboxGroup>
<div class="toggle">
<Toggle
size="sm"
toggled={allSelected(zoneSelected, zoneOptions)}
on:toggle={(e) => {
const { toggled } = e.detail as { toggled: boolean };
zoneSelected = toggled ? [...zoneOptions] : [];
}}
>
<span slot="labelA">全不选</span>
<span slot="labelB">全选</span>
</Toggle>
</div>
<br />
<br />
<CheckboxGroup legendText="状态" id="status" bind:selected={req.status} required={true}>
<Grid narrow>
<Row>
<Column sm={2} md={2} lg={4}><Checkbox value="fresh" labelText={StatusMap['fresh']} /></Column
>
<Column sm={2} md={2} lg={4}
><Checkbox value="scheduled" labelText={StatusMap['scheduled']} /></Column
>
<Column sm={2} md={2} lg={4}><Checkbox value="delay" labelText={StatusMap['delay']} /></Column
>
<Column sm={2} md={2} lg={4}
><Checkbox value="escalated" labelText={StatusMap['escalated']} /></Column
>
{#if IsAdmin(CheckAndGetJWT('parsed').access)}
<Column sm={2} md={2} lg={4}
><Checkbox value="solved" labelText={StatusMap['solved']} /></Column
>
<Column sm={2} md={2} lg={4}
><Checkbox value="canceled" labelText={StatusMap['canceled']} /></Column
>
{/if}
</Row>
</Grid>
</CheckboxGroup>
<div class="toggle">
<Toggle
size="sm"
toggled={allSelected(req.status, statusOptions)}
on:toggle={(e) => {
const { toggled } = e.detail as { toggled: boolean };
req.status = toggled ? [...statusOptions] : [];
}}
>
<span slot="labelA">全不选</span>
<span slot="labelB">全选</span>
</Toggle>
</div>
<br />
<br />
<CheckboxGroup legendText="优先级" id="priority" bind:selected={req.priority} required={true}>
<Grid narrow>
<Row>
<Column sm={2} md={2} lg={4}><Checkbox value="highest" labelText="最高" /></Column>
<Column sm={2} md={2} lg={4}
><Checkbox value="assigned" labelText={PriorityMap['assigned']} /></Column
>
<Column sm={2} md={2} lg={4}
><Checkbox value="mainline" labelText={PriorityMap['mainline']} /></Column
>
<Column sm={2} md={2} lg={4}
><Checkbox value="normal" labelText={PriorityMap['normal']} /></Column
>
<Column sm={2} md={2} lg={4}
><Checkbox value="in-passing" labelText={PriorityMap['in-passing']} /></Column
>
<Column sm={2} md={2} lg={4}
><Checkbox value="least" labelText={PriorityMap['least']} /></Column
>
</Row>
</Grid>
</CheckboxGroup>
<div class="toggle">
<Toggle
size="sm"
toggled={allSelected(req.priority, priorityOptions)}
on:toggle={(e) => {
const { toggled } = e.detail as { toggled: boolean };
req.priority = toggled ? [...priorityOptions] : [];
}}
>
<span slot="labelA">全不选</span>
<span slot="labelB">全选</span>
</Toggle>
</div>
<br />
<br />
<CheckboxGroup legendText="类型" id="category" bind:selected={req.category} required={true}>
<Grid narrow>
<Row>
<Column sm={2} md={2} lg={4}
><Checkbox value="first-install" labelText={CategoryMap['first-install']} /></Column
>
<Column sm={2} md={2} lg={4}
><Checkbox value="client-or-account" labelText={CategoryMap['client-or-account']} /></Column
>
<Column sm={2} md={2} lg={4}
><Checkbox value="ip-or-device" labelText={CategoryMap['ip-or-device']} /></Column
>
<Column sm={2} md={2} lg={4}
><Checkbox value="low-speed" labelText={CategoryMap['low-speed']} /></Column
>
<Column sm={2} md={2} lg={4}
><Checkbox value="others" labelText={CategoryMap['others']} /></Column
>
</Row>
</Grid>
</CheckboxGroup>
<div class="toggle">
<Toggle
size="sm"
toggled={allSelected(req.category, categoryOptions)}
on:toggle={(e) => {
const { toggled } = e.detail as { toggled: boolean };
req.category = toggled ? [...categoryOptions] : [];
}}
>
<span slot="labelA">全不选</span>
<span slot="labelB">全选</span>
</Toggle>
</div>
<br />
<br />
<CheckboxGroup legendText="运营商" id="isp" bind:selected={req.isp} required={true}>
<Grid narrow>
<Row>
<Column sm={2} md={2} lg={4}
><Checkbox value="telecom" labelText={ISPMap['telecom']} /></Column
>
<Column sm={2} md={2} lg={4}><Checkbox value="unicom" labelText={ISPMap['unicom']} /></Column>
<Column sm={2} md={2} lg={4}><Checkbox value="mobile" labelText={ISPMap['mobile']} /></Column>
<Column sm={2} md={2} lg={4}><Checkbox value="others" labelText={ISPMap['others']} /></Column>
<Column sm={2} md={2} lg={4}
><Checkbox value="broadnet" labelText={ISPMap['broadnet']} hidden /></Column
>
<!--暂时藏起来-->
</Row>
</Grid>
</CheckboxGroup>
<div class="toggle">
<Toggle
size="sm"
toggled={allSelected(req.isp, ispOptions)}
on:toggle={(e) => {
const { toggled } = e.detail as { toggled: boolean };
req.isp = toggled ? [...ispOptions] : [];
}}
>
<span slot="labelA">全不选</span>
<span slot="labelB">全选</span>
</Toggle>
</div>
<br />
<br />
<hr />
<br />
<RadioButtonGroup id="order" legendText="排序" bind:selected={order} required={true}>
<RadioButton labelText="优先级从高到低" value="priority" />
<RadioButton labelText="时间从新到旧" value="newest" />
<RadioButton labelText="时间从旧到新" value="oldest" />
</RadioButtonGroup>
<br />
<br />
<NumberInput
min={1}
max={20}
step={1}
bind:value={floor}
allowEmpty={true}
allowDecimal={false}
labelText="只看如下楼层(不填代表查看全部楼层)"
/>
<br />
<br />
<Toggle labelText="只显示预约在今天的预约单" bind:toggled={viewTodayScheduled} disabled={!isScheduledSelected}/>
<br />
<br />
<Button on:click={search}>搜索</Button>
<NotificationQueue bind:this={q} />

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
import { CheckAndGetJWT, Guard } from '$lib/jwt';
import { IsAdmin, IsOperator, PriorityMap, CategoryMap } from '$lib/types/enum';
import { onMount } from 'svelte';
import { Button, NotificationQueue } from 'carbon-components-svelte';
import Return from 'carbon-icons-svelte/lib/Return.svelte';
import type { FilterTicketsReq } from '$lib/types/apiRequest';
import type { Ticket } from '$lib/types/apiResponse';
import { FilterTickets } from '$lib/api';
import { criteria } from '$lib/states/ticketCriteriaSearch.svelte';
import OperatorTicket from '$lib/components/Ticket/OperatorTicket.svelte';
import TicketDetail from '$lib/components/TraceDetail/TicketDetail.svelte';
import { TicketModal } from '$lib/states/ticketDetails.svelte';
import type { RFC3339 } from '$lib/types/RFC3339';
let q: NotificationQueue;
let tickets = $state([] as Ticket[]);
let ticketEmpty = $state(false);
let ok = $state(false);
onMount(() => (Guard(IsOperator), fetchTickets1()));
function toMs(rfc3339: RFC3339 | string | undefined) {
const t = rfc3339 ? Date.parse(rfc3339) : NaN;
return Number.isFinite(t) ? t : 0;
}
/** 从房间号字符串推导楼层109 -> 1, 1033 -> 10 */
function getFloorFromRoom(room: string | undefined | null): number | null {
//console.log('getFloorFromRoom', { room });
if (!room) return null;
const digits = String(room).match(/\d+/g)?.join('') ?? '';
if (digits.length < 3 || digits.length > 4) return null;
const roomNum = Number.parseInt(digits, 10);
if (!Number.isFinite(roomNum)) return null;
const floor = Math.floor(roomNum / 100);
//console.log('getFloorFromRoom', { room, digits, roomNum, floor });
return Number.isFinite(floor) ? floor : null;
}
function postProcess() {
if (ticketEmpty) {
return;
}
if (criteria._order === 'newest') {
tickets = [...tickets].sort((a, b) => toMs(b.submitted_at) - toMs(a.submitted_at));
}
if (criteria._order === 'oldest') {
tickets = [...tickets].sort((a, b) => toMs(a.submitted_at) - toMs(b.submitted_at));
}
if (criteria._floor !== null && criteria._floor !== undefined && criteria._floor !== 0) {
tickets = tickets.filter((t) => getFloorFromRoom(t?.issuer?.room) === criteria._floor);
}
if (criteria._view_today_scheduled) {
const todayStart = new Date().setHours(0, 0, 0, 0);
const todayEnd = new Date().setHours(23, 59, 59, 999);
tickets = tickets.filter((t) => t.status !== 'scheduled' || (toMs(t.appointed_at) >= todayStart && toMs(t.appointed_at) <= todayEnd));
}
ok = true;
ticketEmpty = tickets.length === 0;
return;
}
async function fetchTickets1() {
ok = false;
try {
let req: FilterTicketsReq = {} as FilterTicketsReq;
Object.assign(req, criteria.r);
let res = await FilterTickets(req);
if (!res.success) {
throw new Error(res.msg || '获取工单列表失败');
}
tickets = res.tickets;
if (!tickets) {
ticketEmpty = true;
}
postProcess();
} catch (e: any) {
const errMsg = e.response?.data?.msg || e.message || '未知错误';
q.add({
kind: 'error',
title: '获取工单列表失败',
subtitle: errMsg,
timeout: 3000
});
return;
}
}
async function refreshTickets(){
await fetchTickets1();
}
</script>
<h1>报修单检索结果</h1>
<br />
<hr />
<br />
<p>按照您提供的条件,获得的检索结果。</p>
<br />
<div
style="display: flex; justify-content: flex-end; transform: translate(-17px,0px); margin-bottom: 15px;"
>
<Button href="/op/ticket_search">修改条件<Return /></Button>
</div>
{#if !ok}
<p>处理中,请稍等...</p>
{/if}
{#if ticketEmpty === false}
{#each tickets as t}
<OperatorTicket {t} />
{/each}
{:else}
<span>没有找到符合条件的报修单。</span>
{/if}
<TicketDetail t={TicketModal.NowTicket} bind:open={TicketModal.Opened} src={TicketModal.SRC} onTicketChanged={refreshTickets}/>
<NotificationQueue bind:this={q} />

View File

@@ -0,0 +1,366 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
import {
TextInput,
RadioButtonGroup,
RadioButton,
Select,
SelectItem,
SelectItemGroup,
Button,
Checkbox,
ComposedModal,
ModalBody,
ModalFooter,
ModalHeader,
NotificationQueue,
Loading
} from 'carbon-components-svelte';
import type { RegisterReq } from '$lib/types/apiRequest';
import { invalidState } from '$lib/types/invalidState.svelte';
import { Register } from '$lib/api';
import { TheLastPage } from '$lib/states/theLastPage.svelte';
import { onMount } from 'svelte';
import { IsUnregistered } from '$lib/types/enum';
import { Guard } from '$lib/jwt';
let q: NotificationQueue;
let notLoading: boolean = $state(true);
onMount(() => Guard(IsUnregistered));
//注册请求体状态变量
let req = $state({
block: '0'
} as unknown as RegisterReq);
//模态框状态变量
let checked = $state(false);
let open = $state(false);
//无效状态变量
let name = new invalidState();
let sid = new invalidState();
let account = new invalidState();
let phone = new invalidState();
let block = new invalidState();
let room = new invalidState();
//检查输入合法性
function check(): boolean {
let ok = false;
// 重置所有无效状态
name.reset();
sid.reset();
account.reset();
phone.reset();
block.reset();
room.reset();
// 首先姓名学号不能超过15个字符且不能为空
name.assert(req.name.length > 0, '姓名不能为空');
name.assert(req.name.length <= 15, '姓名不能超过15个字符');
sid.assert(req.sid.length > 0, '学号不能为空');
sid.assert(req.sid.length <= 15, '学号不能超过15个字符');
// 然后校园网账号和手机号是中国大陆的11位手机号码
const phoneRegex = /^1[3-9]\d{9}$/;
account.assert(
req.isp === 'others' || phoneRegex.test(req.account),
'校园网账号应为有效的11位手机号'
);
account.assert(req.isp !== 'others' || req.account.length > 0, '请输入您的校园网账号');
account.assert(req.isp !== 'others' || req.account.length <= 15, '校园网账号不能超过15个字符');
phone.assert(phoneRegex.test(req.phone), '联系电话应为有效的11位手机号');
// 接着宿舍楼不能为空且房间号不能超过5个字符且不能为空
block.assert(req.block !== '0', '请选择宿舍楼');
room.assert(req.room.length > 0, '房间号不能为空');
room.assert(
req.block === 'other' || /^[0-9]{1,4}$/.test(req.room),
'请填写一个5位以内的纯数字...'
);
//最后,总结断言结果
if (
name.notOK ||
sid.notOK ||
account.notOK ||
phone.notOK ||
block.notOK ||
room.notOK ||
req.isp === undefined
) {
ok = false;
} else {
ok = true;
}
return ok;
}
// 在不合法时跳转到对应的地方以便用户修改
function jump() {
if (name.notOK) {
document.getElementById('name')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (sid.notOK) {
document.getElementById('sid')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (account.notOK) {
document.getElementById('account')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (phone.notOK) {
document.getElementById('phone')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (block.notOK) {
document.getElementById('block')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (room.notOK) {
document.getElementById('room')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
if (req.isp === undefined) {
document.getElementById('isp')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
q.add({
kind: 'error',
title: '请选择运营商',
subtitle: '请选择您校园卡的运营商',
timeout: 5000
});
}
}
async function submit() {
open = false;
checked = false;
notLoading = false;
try {
const res = await Register(req);
notLoading = true;
if (!res.success) {
throw new Error(res.msg || '注册失败.........');
}
q.add({
kind: 'success',
title: '注册成功',
timeout: 3000
});
setTimeout(() => {
TheLastPage.Write('/');
window.location.href = '/login';
}, 3900);
} catch (e: any) {
notLoading = true;
console.error('register fail:', e);
const errMsg = e.response?.data?.msg || e.message || '未知错误';
q.add({
kind: 'error',
title: '注册失败',
subtitle: errMsg + ',请重试',
timeout: 5000
});
}
}
</script>
<h1>注册</h1>
<br />
<hr />
<br />
<p style="font-size: 15px;">
<i
>您似乎还没有注册。在使用该系统之前,请先注册您的个人信息。这些信息将被用于我们上门为您维修网络问题。</i
>
</p>
<br />
<TextInput
id="name"
labelText="姓名"
placeholder="请输入您的真实姓名..."
bind:value={req.name}
invalid={name.notOK}
invalidText={name.txt}
/>
<br />
<br />
<TextInput
id="sid"
labelText="学号"
placeholder="请输入您的学号..."
bind:value={req.sid}
invalid={sid.notOK}
invalidText={sid.txt}
/>
<br />
<br />
<RadioButtonGroup
id="isp"
legendText="请选择您校园卡的运营商"
bind:selected={req.isp}
required={true}
>
<RadioButton labelText="电信" value="telecom" />
<RadioButton labelText="联通" value="unicom" />
<RadioButton labelText="移动" value="mobile" />
<RadioButton labelText="其它" value="others" />
</RadioButtonGroup>
<br />
<br />
<TextInput
id="account"
labelText="校园网账号"
placeholder="请输入您校园卡的手机号..."
bind:value={req.account}
invalid={account.notOK}
invalidText={account.txt}
/>
<br />
<br />
<hr />
<br />
<br />
<TextInput
id="phone"
labelText="电话"
placeholder="请输入您的联系电话..."
bind:value={req.phone}
invalid={phone.notOK}
invalidText={phone.txt}
/>
<br />
<br />
<Select
id="block"
labelText="宿舍楼"
bind:selected={req.block}
invalid={block.notOK}
invalidText={block.txt}
>
<SelectItem value="0" text="请选择您的所住的宿舍楼..." disabled hidden />
<SelectItemGroup label="凤翔宿舍区">
<SelectItem value="1" text="1栋" />
<SelectItem value="2" text="2栋" />
<SelectItem value="3" text="3栋" />
<SelectItem value="4" text="4栋" />
<SelectItem value="5" text="5栋" />
<SelectItem value="6" text="6栋" />
</SelectItemGroup>
<SelectItemGroup label="北门宿舍区">
<SelectItem value="7" text="7栋" />
<SelectItem value="8" text="8栋" />
<SelectItem value="9" text="9栋" />
<SelectItem value="10" text="10栋" />
<SelectItem value="11" text="11栋" />
</SelectItemGroup>
<SelectItemGroup label="东门宿舍区">
<SelectItem value="12" text="12栋" />
<SelectItem value="13" text="13栋" />
<SelectItem value="14" text="14栋" />
<SelectItem value="15" text="15栋" />
<SelectItem value="20" text="20栋" />
<SelectItem value="21" text="21栋" />
<SelectItem value="22" text="22栋" />
</SelectItemGroup>
<SelectItemGroup label="歧头山宿舍区">
<SelectItem value="16" text="16栋" />
<SelectItem value="17" text="17栋" />
<SelectItem value="18" text="18栋" />
<SelectItem value="19" text="19栋" />
</SelectItemGroup>
<SelectItemGroup label="香晖苑">
<SelectItem value="XHA" text="香晖苑-A栋" />
<SelectItem value="XHB" text="香晖苑-B栋" />
<SelectItem value="XHC" text="香晖苑-C栋" />
<SelectItem value="XHD" text="香晖苑-D栋" />
</SelectItemGroup>
<SelectItemGroup label="朝晖苑">
<SelectItem value="ZH" text="朝晖苑" />
</SelectItemGroup>
<SelectItemGroup label="其它">
<SelectItem value="other" text="其它" />
</SelectItemGroup>
</Select>
<br />
<br />
<TextInput
id="room"
labelText="房间号"
placeholder="请输入您所住的房间..."
bind:value={req.room}
invalid={room.notOK}
invalidText={room.txt}
/>
<br />
<br />
<br />
<Button
on:click={() => {
check() ? (open = true) : jump();
}}>提交注册</Button
>
<ComposedModal
bind:open
on:close={() => {
((open = false), (checked = false));
}}
class="mobile-floating-modal"
>
<ModalHeader title="确认您的信息" />
<ModalBody hasForm>
<Checkbox labelText="我确认所填信息准确无误,真实有效,且未盗用他人信息" bind:checked />
<br />
<br />
</ModalBody>
<ModalFooter>
<Button kind="secondary" on:click={() => ((open = false), (checked = false))}>取消</Button>
<Button
kind="primary"
disabled={!checked}
on:click={() => {
submit();
}}>确认并提交</Button
>
</ModalFooter>
</ComposedModal>
<NotificationQueue bind:this={q} />
<Loading active={!notLoading} />
<style>
:global(.mobile-floating-modal.bx--modal) {
@media (max-width: 672px) {
display: flex !important;
align-items: center !important;
justify-content: center !important;
/* 确保背景色存在 (Carbon默认有但为了保险起见) */
background-color: rgba(22, 22, 22, 0.5) !important;
}
}
:global(.mobile-floating-modal .bx--modal-container) {
@media (max-width: 672px) {
width: 90% !important;
max-width: 400px !important;
height: auto !important;
max-height: 85vh !important;
position: relative !important;
margin: 0 !important;
top: auto !important;
left: auto !important;
transform: none !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4) !important;
}
}
:global(.mobile-floating-modal .bx--modal-content) {
@media (max-width: 672px) {
max-height: 60vh !important;
overflow-y: auto !important;
margin-bottom: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import OperatorTicket from '$lib/components/Ticket/OperatorTicket.svelte';
import TicketDetail from '$lib/components/TraceDetail/TicketDetail.svelte';
import UserTicket from '$lib/components/Ticket/UserTicket.svelte';
import { sample1, sample2 } from '$lib/testData/ticket';
import { Button } from 'carbon-components-svelte';
import { TicketModal } from '$lib/states/ticketDetails.svelte';
import Contract from 'carbon-pictograms-svelte/lib/Contract.svelte';
import { onMount } from 'svelte';
import { IsUser } from '$lib/types/enum';
import { CheckAndGetJWT, Guard } from '$lib/jwt';
import type { Ticket } from '$lib/types/apiResponse';
import { GetTicket } from '$lib/api';
import { NotificationQueue } from 'carbon-components-svelte';
let q: NotificationQueue;
let tickets = $state([] as Ticket[]);
onMount(() => (Guard(IsUser), fetchTickets()));
async function fetchTickets() {
try {
let res = await GetTicket(CheckAndGetJWT('parsed').openid);
if (!res.success) {
throw new Error(res.msg || '获取报修记录失败');
}
tickets = res.tickets;
} catch (e: any) {
const errMsg = e.response?.data?.msg || e.message || '未知错误';
q.add({
kind: 'error',
title: '获取报修记录失败',
subtitle: errMsg,
timeout: 3000
});
return;
}
}
async function refreshTickets1(){
await fetchTickets();
}
</script>
<h1 style="display: flex; align-items: center;">
<span><Contract /></span>
<span style="margin-left: 8px;">报修记录</span>
</h1>
<br />
<hr />
<br />
<p>
这里将显示您提交的所有报修记录,由于各种原因,我们可能只会显示您最近几个报修单。点击单子可展开详情。
</p>
<br />
<div
style="display: flex; justify-content: flex-end; transform: translate(-17px,0px); margin-bottom: 15px;"
>
<Button href="/repair/new">提交新报修</Button>
</div>
<OperatorTicket t={sample1} />
<OperatorTicket t={sample2} />
<hr />
{#each tickets as t}
<UserTicket {t} />
{/each}
<TicketDetail t={TicketModal.NowTicket} bind:open={TicketModal.Opened} src={TicketModal.SRC} onTicketChanged={refreshTickets1}/>
<NotificationQueue bind:this={q} />

View File

@@ -0,0 +1,208 @@
<script lang="ts">
import { CheckAndGetJWT, Guard } from '$lib/jwt';
import type { NewTicketReq } from '$lib/types/apiRequest';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
import { RFC3339 } from '$lib/types/RFC3339';
import { onMount } from 'svelte';
import { IsUser } from '$lib/types/enum';
import {
DatePicker,
DatePickerInput,
RadioButtonGroup,
RadioButton,
TextArea,
Button,
NotificationQueue,
Loading
} from 'carbon-components-svelte';
import { IsRFC3339 } from '$lib/types/RFC3339';
import { invalidState } from '$lib/types/invalidState.svelte';
import { NewTicket } from '$lib/api';
import { goto } from '$app/navigation';
let notLoading: boolean = $state(true);
let q: NotificationQueue;
let r = $state({} as NewTicketReq);
function onOccurDateChange(event: CustomEvent) {
const { dateStr } = event.detail;
if (dateStr) {
r.occur_at = RFC3339(dateStr);
}
}
function onAppointDateChange(event: CustomEvent) {
const { dateStr } = event.detail;
if (dateStr) {
const date = new Date(dateStr);
date.setHours(16, 30, 0, 0); // Set time to 16:30:00
r.appointed_at = RFC3339(date);
}
}
function handleSubmit() {
console.log('提交的表单数据:', r);
check() ? submit() : jumpInvalid();
}
let occurAt = new invalidState();
let appointedAt = new invalidState();
let description = new invalidState();
let notes = new invalidState();
function check(): boolean {
notLoading = false;
let ok = false;
occurAt.reset();
appointedAt.reset();
description.reset();
notes.reset();
occurAt.assert(!r.occur_at || IsRFC3339(r.occur_at), '请输入正确的故障发生时间');
appointedAt.assert(!r.appointed_at || IsRFC3339(r.appointed_at), '请输入正确的预约时间');
description.assert(r.description && r.description.length > 0, '请填写故障描述');
description.assert(r.description.length <= 100, '字数太多了请控制在100字以内');
notes.assert(!r.notes || r.notes.length <= 100, '字数太多了...请控制在100字以内');
if (r.category == undefined) {
r.category = 'others';
}
if (!r.occur_at) {
r.occur_at = undefined;
}
if (!r.appointed_at) {
r.appointed_at = undefined; //防止序列化问题
}
notLoading = true;
if (occurAt.notOK || appointedAt.notOK || description.notOK || notes.notOK) {
ok = false;
} else {
ok = true;
}
return ok;
}
async function submit() {
let issuerSID = CheckAndGetJWT('parsed').sid;
r.issuer_sid = issuerSID;
try {
notLoading = false;
let res = await NewTicket(r);
notLoading = true;
if (!res.success) {
throw new Error(res.msg || '提交失败.........');
}
q.add({
kind: 'success',
title: '提交成功',
timeout: 3000
});
setTimeout(() => goto('/repair'), 3900);
} catch (e: any) {
notLoading = true;
const errMsg = e.response?.data?.msg || e.message || '未知错误';
q.add({
kind: 'error',
title: '提交失败',
subtitle: errMsg + ',请重试',
timeout: 5000
});
}
}
function jumpInvalid() {
if (occurAt.notOK) {
document.getElementById('occur_at')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (appointedAt.notOK) {
document
.getElementById('appointed_at')
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (description.notOK) {
document
.getElementById('description')
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (notes.notOK) {
document.getElementById('notes')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
onMount(() => Guard(IsUser));
</script>
<h1>提交新报修</h1>
<br />
<hr />
<br />
<p>
<i
>请仔细填写这张报修表,在成功提交后,会有网维的工作人员在您预约的时间通过电话联系您或上门维修您的问题。</i
>
</p>
<br />
<DatePicker datePickerType="single" on:change={onOccurDateChange}>
<DatePickerInput
labelText="故障是在什么时候发生的?"
placeholder="记不清楚可不填"
invalid={occurAt.notOK}
invalidText={occurAt.txt}
/>
</DatePicker>
<br />
<br />
<RadioButtonGroup
legendText="故障大概是什么问题?(准确填写有助于我们维修)"
orientation="vertical"
bind:selected={r.category}
required={true}
>
<RadioButton labelText="需要新安装宽带" value="first-install" />
<RadioButton labelText="IP地址或者网络设备问题" value="ip-or-device" />
<RadioButton labelText="电脑软件或者账号的问题" value="client-or-account" />
<RadioButton labelText="网速问题" value="low-speed" />
<RadioButton labelText="其它问题/不清楚" value="others" />
</RadioButtonGroup>
<br />
<br />
<TextArea
labelText="故障描述"
placeholder="请告诉我们你遇到了什么网络问题,越详细越好~"
bind:value={r.description}
invalid={description.notOK}
invalidText={description.txt}
/>
<br />
<br />
<DatePicker datePickerType="single" on:change={onAppointDateChange}>
<DatePickerInput
labelText="预约我们上门维修的日期"
placeholder="当天4:30~6:00您本人需要在宿舍"
invalid={appointedAt.notOK}
invalidText={appointedAt.txt}
/>
</DatePicker>
<br />
<br />
<hr />
<br />
<br />
<TextArea
labelText="备注"
placeholder="其它您需要告诉我们的事情,没有可不填"
bind:value={r.notes}
invalid={notes.notOK}
invalidText={notes.txt}
/>
<br />
<br />
<Button on:click={handleSubmit}>提交</Button>
<NotificationQueue bind:this={q} />
<Loading active={!notLoading} />